/**
 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */
/**
 * @module link/linkcommand
 */
import { Command } from 'ckeditor5/src/core';
import { findAttributeRange } from 'ckeditor5/src/typing';
import { Collection, first, toMap } from 'ckeditor5/src/utils';
import AutomaticDecorators from './utils/automaticdecorators';
import { isLinkableElement } from './utils';
/**
 * The link command. It is used by the {@link module:link/link~Link link feature}.
 */
export default class LinkCommand extends Command {
    constructor() {
        super(...arguments);
        /**
         * A collection of {@link module:link/utils/manualdecorator~ManualDecorator manual decorators}
         * corresponding to the {@link module:link/linkconfig~LinkConfig#decorators decorator configuration}.
         *
         * You can consider it a model with states of manual decorators added to the currently selected link.
         */
        this.manualDecorators = new Collection();
        /**
         * An instance of the helper that ties together all {@link module:link/linkconfig~LinkDecoratorAutomaticDefinition}
         * that are used by the {@glink features/link link} and the {@glink features/images/images-linking linking images} features.
         */
        this.automaticDecorators = new AutomaticDecorators();
    }
    /**
     * Synchronizes the state of {@link #manualDecorators} with the currently present elements in the model.
     */
    restoreManualDecoratorStates() {
        for (const manualDecorator of this.manualDecorators) {
            manualDecorator.value = this._getDecoratorStateFromModel(manualDecorator.id);
        }
    }
    /**
     * @inheritDoc
     */
    refresh() {
        const model = this.editor.model;
        const selection = model.document.selection;
        const selectedElement = selection.getSelectedElement() || first(selection.getSelectedBlocks());
        // A check for any integration that allows linking elements (e.g. `LinkImage`).
        // Currently the selection reads attributes from text nodes only. See #7429 and #7465.
        if (isLinkableElement(selectedElement, model.schema)) {
            this.value = selectedElement.getAttribute('linkHref');
            this.isEnabled = model.schema.checkAttribute(selectedElement, 'linkHref');
        }
        else {
            this.value = selection.getAttribute('linkHref');
            this.isEnabled = model.schema.checkAttributeInSelection(selection, 'linkHref');
        }
        for (const manualDecorator of this.manualDecorators) {
            manualDecorator.value = this._getDecoratorStateFromModel(manualDecorator.id);
        }
    }
    /**
     * Executes the command.
     *
     * When the selection is non-collapsed, the `linkHref` attribute will be applied to nodes inside the selection, but only to
     * those nodes where the `linkHref` attribute is allowed (disallowed nodes will be omitted).
     *
     * When the selection is collapsed and is not inside the text with the `linkHref` attribute, a
     * new {@link module:engine/model/text~Text text node} with the `linkHref` attribute will be inserted in place of the caret, but
     * only if such element is allowed in this place. The `_data` of the inserted text will equal the `href` parameter.
     * The selection will be updated to wrap the just inserted text node.
     *
     * When the selection is collapsed and inside the text with the `linkHref` attribute, the attribute value will be updated.
     *
     * # Decorators and model attribute management
     *
     * There is an optional argument to this command that applies or removes model
     * {@glink framework/architecture/editing-engine#text-attributes text attributes} brought by
     * {@link module:link/utils/manualdecorator~ManualDecorator manual link decorators}.
     *
     * Text attribute names in the model correspond to the entries in the {@link module:link/linkconfig~LinkConfig#decorators
     * configuration}.
     * For every decorator configured, a model text attribute exists with the "link" prefix. For example, a `'linkMyDecorator'` attribute
     * corresponds to `'myDecorator'` in the configuration.
     *
     * To learn more about link decorators, check out the {@link module:link/linkconfig~LinkConfig#decorators `config.link.decorators`}
     * documentation.
     *
     * Here is how to manage decorator attributes with the link command:
     *
     * ```ts
     * const linkCommand = editor.commands.get( 'link' );
     *
     * // Adding a new decorator attribute.
     * linkCommand.execute( 'http://example.com', {
     * 	linkIsExternal: true
     * } );
     *
     * // Removing a decorator attribute from the selection.
     * linkCommand.execute( 'http://example.com', {
     * 	linkIsExternal: false
     * } );
     *
     * // Adding multiple decorator attributes at the same time.
     * linkCommand.execute( 'http://example.com', {
     * 	linkIsExternal: true,
     * 	linkIsDownloadable: true,
     * } );
     *
     * // Removing and adding decorator attributes at the same time.
     * linkCommand.execute( 'http://example.com', {
     * 	linkIsExternal: false,
     * 	linkFoo: true,
     * 	linkIsDownloadable: false,
     * } );
     * ```
     *
     * **Note**: If the decorator attribute name is not specified, its state remains untouched.
     *
     * **Note**: {@link module:link/unlinkcommand~UnlinkCommand#execute `UnlinkCommand#execute()`} removes all
     * decorator attributes.
     *
     * @fires execute
     * @param href Link destination.
     * @param manualDecoratorIds The information about manual decorator attributes to be applied or removed upon execution.
     */
    execute(href, manualDecoratorIds = {}) {
        const model = this.editor.model;
        const selection = model.document.selection;
        // Stores information about manual decorators to turn them on/off when command is applied.
        const truthyManualDecorators = [];
        const falsyManualDecorators = [];
        for (const name in manualDecoratorIds) {
            if (manualDecoratorIds[name]) {
                truthyManualDecorators.push(name);
            }
            else {
                falsyManualDecorators.push(name);
            }
        }
        model.change(writer => {
            // If selection is collapsed then update selected link or insert new one at the place of caret.
            if (selection.isCollapsed) {
                const position = selection.getFirstPosition();
                // When selection is inside text with `linkHref` attribute.
                if (selection.hasAttribute('linkHref')) {
                    const linkText = extractTextFromSelection(selection);
                    // Then update `linkHref` value.
                    let linkRange = findAttributeRange(position, 'linkHref', selection.getAttribute('linkHref'), model);
                    if (selection.getAttribute('linkHref') === linkText) {
                        linkRange = this._updateLinkContent(model, writer, linkRange, href);
                    }
                    writer.setAttribute('linkHref', href, linkRange);
                    truthyManualDecorators.forEach(item => {
                        writer.setAttribute(item, true, linkRange);
                    });
                    falsyManualDecorators.forEach(item => {
                        writer.removeAttribute(item, linkRange);
                    });
                    // Put the selection at the end of the updated link.
                    writer.setSelection(writer.createPositionAfter(linkRange.end.nodeBefore));
                }
                // If not then insert text node with `linkHref` attribute in place of caret.
                // However, since selection is collapsed, attribute value will be used as data for text node.
                // So, if `href` is empty, do not create text node.
                else if (href !== '') {
                    const attributes = toMap(selection.getAttributes());
                    attributes.set('linkHref', href);
                    truthyManualDecorators.forEach(item => {
                        attributes.set(item, true);
                    });
                    const { end: positionAfter } = model.insertContent(writer.createText(href, attributes), position);
                    // Put the selection at the end of the inserted link.
                    // Using end of range returned from insertContent in case nodes with the same attributes got merged.
                    writer.setSelection(positionAfter);
                }
                // Remove the `linkHref` attribute and all link decorators from the selection.
                // It stops adding a new content into the link element.
                ['linkHref', ...truthyManualDecorators, ...falsyManualDecorators].forEach(item => {
                    writer.removeSelectionAttribute(item);
                });
            }
            else {
                // If selection has non-collapsed ranges, we change attribute on nodes inside those ranges
                // omitting nodes where the `linkHref` attribute is disallowed.
                const ranges = model.schema.getValidRanges(selection.getRanges(), 'linkHref');
                // But for the first, check whether the `linkHref` attribute is allowed on selected blocks (e.g. the "image" element).
                const allowedRanges = [];
                for (const element of selection.getSelectedBlocks()) {
                    if (model.schema.checkAttribute(element, 'linkHref')) {
                        allowedRanges.push(writer.createRangeOn(element));
                    }
                }
                // Ranges that accept the `linkHref` attribute. Since we will iterate over `allowedRanges`, let's clone it.
                const rangesToUpdate = allowedRanges.slice();
                // For all selection ranges we want to check whether given range is inside an element that accepts the `linkHref` attribute.
                // If so, we don't want to propagate applying the attribute to its children.
                for (const range of ranges) {
                    if (this._isRangeToUpdate(range, allowedRanges)) {
                        rangesToUpdate.push(range);
                    }
                }
                for (const range of rangesToUpdate) {
                    let linkRange = range;
                    if (rangesToUpdate.length === 1) {
                        // Current text of the link in the document.
                        const linkText = extractTextFromSelection(selection);
                        if (selection.getAttribute('linkHref') === linkText) {
                            linkRange = this._updateLinkContent(model, writer, range, href);
                            writer.setSelection(writer.createSelection(linkRange));
                        }
                    }
                    writer.setAttribute('linkHref', href, linkRange);
                    truthyManualDecorators.forEach(item => {
                        writer.setAttribute(item, true, linkRange);
                    });
                    falsyManualDecorators.forEach(item => {
                        writer.removeAttribute(item, linkRange);
                    });
                }
            }
        });
    }
    /**
     * Provides information whether a decorator with a given name is present in the currently processed selection.
     *
     * @param decoratorName The name of the manual decorator used in the model
     * @returns The information whether a given decorator is currently present in the selection.
     */
    _getDecoratorStateFromModel(decoratorName) {
        const model = this.editor.model;
        const selection = model.document.selection;
        const selectedElement = selection.getSelectedElement();
        // A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
        // Currently the selection reads attributes from text nodes only. See #7429 and #7465.
        if (isLinkableElement(selectedElement, model.schema)) {
            return selectedElement.getAttribute(decoratorName);
        }
        return selection.getAttribute(decoratorName);
    }
    /**
     * Checks whether specified `range` is inside an element that accepts the `linkHref` attribute.
     *
     * @param range A range to check.
     * @param allowedRanges An array of ranges created on elements where the attribute is accepted.
     */
    _isRangeToUpdate(range, allowedRanges) {
        for (const allowedRange of allowedRanges) {
            // A range is inside an element that will have the `linkHref` attribute. Do not modify its nodes.
            if (allowedRange.containsRange(range)) {
                return false;
            }
        }
        return true;
    }
    /**
     * Updates selected link with a new value as its content and as its href attribute.
     *
     * @param model Model is need to insert content.
     * @param writer Writer is need to create text element in model.
     * @param range A range where should be inserted content.
     * @param href A link value which should be in the href attribute and in the content.
     */
    _updateLinkContent(model, writer, range, href) {
        const text = writer.createText(href, { linkHref: href });
        return model.insertContent(text, range);
    }
}
// Returns a text of a link under the collapsed selection or a selection that contains the entire link.
function extractTextFromSelection(selection) {
    if (selection.isCollapsed) {
        const firstPosition = selection.getFirstPosition();
        return firstPosition.textNode && firstPosition.textNode.data;
    }
    else {
        const rangeItems = Array.from(selection.getFirstRange().getItems());
        if (rangeItems.length > 1) {
            return null;
        }
        const firstNode = rangeItems[0];
        if (firstNode.is('$text') || firstNode.is('$textProxy')) {
            return firstNode.data;
        }
        return null;
    }
}
